--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 1e0b869c2eaad4d1e4b39b21f25e0b42cf03734a
Parents : 4186ea9
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-02T22:29:13-05:00
feat(notification): integrate GlobalEmitter for notification updates on conversation read actions and improve NotificationBell component for real-time sync
Changes
8 files changed, 669 insertions(+), 3 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/NotificationBell.vue b/meshchatx/src/frontend/components/NotificationBell.vue
index 27c96cdd..24c44072 100644
--- a/meshchatx/src/frontend/components/NotificationBell.vue
+++ b/meshchatx/src/frontend/components/NotificationBell.vue
@@ -162,6 +162,7 @@ import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import Utils from "../js/Utils";
import WebSocketConnection from "../js/WebSocketConnection";
import GlobalState from "../js/GlobalState";
+import GlobalEmitter from "../js/GlobalEmitter";
import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
export default {
@@ -214,14 +215,14 @@ export default {
clearInterval(this.reloadInterval);
}
WebSocketConnection.off("message", this.onWebsocketMessage);
+ GlobalEmitter.off("notifications-changed", this.onNotificationsChanged);
},
mounted() {
this.loadNotifications();
WebSocketConnection.on("message", this.onWebsocketMessage);
+ GlobalEmitter.on("notifications-changed", this.onNotificationsChanged);
this.reloadInterval = setInterval(() => {
- if (this.isDropdownOpen) {
- this.loadNotifications({ updateList: false });
- }
+ this.loadNotifications({ updateList: this.isDropdownOpen });
}, 5000);
},
methods: {
@@ -234,6 +235,12 @@ export default {
}
return GlobalState.authenticated;
},
+ onNotificationsChanged() {
+ if (!this.shouldFetchNotifications()) {
+ return;
+ }
+ this.loadNotifications({ updateList: this.isDropdownOpen });
+ },
async toggleDropdown(event) {
this.isDropdownOpen = !this.isDropdownOpen;
if (this.isDropdownOpen) {
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index f6a85e1b..3879e6e2 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -6844,6 +6844,7 @@ export default {
// mark conversation as read on server
try {
await window.api.post(`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`);
+ GlobalEmitter.emit("notifications-changed");
} catch (e) {
// do nothing if failed to mark as read
console.log(e);
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 43841ca9..4000d637 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -986,6 +986,7 @@ export default {
await window.api.post("/api/v1/lxmf/conversations/bulk-mark-as-read", {
destination_hashes,
});
+ GlobalEmitter.emit("notifications-changed");
await this.getConversations();
ToastUtils.success(this.$t("messages.marked_read"));
} catch {
diff --git a/tests/backend/test_notification_user_facing_filter.py b/tests/backend/test_notification_user_facing_filter.py
index 416d5a16..b15ed13e 100644
--- a/tests/backend/test_notification_user_facing_filter.py
+++ b/tests/backend/test_notification_user_facing_filter.py
@@ -749,3 +749,143 @@ class TestNotificationsGetUserFacingFilter:
body = await self._get(bell_app, unread="true", limit=10)
assert body["unread_count"] == 0
assert body["notifications"] == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
+class TestNotificationsMarkReadSync:
+ """Reading a conversation must clear the bell without opening the dropdown."""
+
+ async def _get(self, app, **params):
+ aio_app = _build_aio_app(app)
+ async with TestClient(TestServer(aio_app)) as client:
+ resp = await client.get("/api/v1/notifications", params=params)
+ assert resp.status == 200
+ return await resp.json()
+
+ async def _post(self, app, path, payload=None):
+ aio_app = _build_aio_app(app)
+ async with TestClient(TestServer(aio_app)) as client:
+ resp = await client.post(path, json=payload or {})
+ assert resp.status == 200
+ return await resp.json()
+
+ async def test_mark_conversation_read_api_clears_bell(self, bell_app):
+ bell_app.database.messages.upsert_lxmf_message(
+ _mk_message(
+ msg_hash="m1",
+ peer_hash=PEER_HASH,
+ content="hello",
+ timestamp=1_700_000_000,
+ ),
+ )
+ before = await self._get(bell_app, unread="true", limit=10)
+ assert before["unread_count"] == 1
+ assert len(before["notifications"]) == 1
+
+ await self._post(
+ bell_app,
+ f"/api/v1/lxmf/conversations/{PEER_HASH}/mark-as-read",
+ )
+
+ after = await self._get(bell_app, unread="true", limit=10)
+ assert after["unread_count"] == 0
+ assert after["notifications"] == []
+
+ async def test_bulk_mark_read_api_clears_bell(self, bell_app):
+ bell_app.database.messages.upsert_lxmf_message(
+ _mk_message(
+ msg_hash="m1",
+ peer_hash=PEER_HASH,
+ content="one",
+ timestamp=1_700_000_000,
+ ),
+ )
+ bell_app.database.messages.upsert_lxmf_message(
+ _mk_message(
+ msg_hash="m2",
+ peer_hash=PEER_HASH_2,
+ content="two",
+ timestamp=1_700_000_100,
+ ),
+ )
+ before = await self._get(bell_app, unread="true", limit=10)
+ assert before["unread_count"] == 2
+
+ await self._post(
+ bell_app,
+ "/api/v1/lxmf/conversations/bulk-mark-as-read",
+ {"destination_hashes": [PEER_HASH, PEER_HASH_2]},
+ )
+
+ after = await self._get(bell_app, unread="true", limit=10)
+ assert after["unread_count"] == 0
+ assert after["notifications"] == []
+
+ async def test_conversation_list_and_bell_agree_after_mark_read(self, bell_app):
+ bell_app.database.messages.upsert_lxmf_message(
+ _mk_message(
+ msg_hash="m1",
+ peer_hash=PEER_HASH,
+ content="hello",
+ timestamp=1_700_000_000,
+ ),
+ )
+ aio_app = _build_aio_app(bell_app)
+ async with TestClient(TestServer(aio_app)) as client:
+ conv_before = await client.get(
+ "/api/v1/lxmf/conversations",
+ params={"filter_unread": "true"},
+ )
+ assert conv_before.status == 200
+ assert len((await conv_before.json())["conversations"]) == 1
+
+ await client.post(f"/api/v1/lxmf/conversations/{PEER_HASH}/mark-as-read")
+
+ conv_after = await client.get(
+ "/api/v1/lxmf/conversations",
+ params={"filter_unread": "true"},
+ )
+ bell_after = await client.get(
+ "/api/v1/notifications",
+ params={"unread": "true", "limit": 10},
+ )
+ assert conv_after.status == 200
+ assert bell_after.status == 200
+ conv_body = await conv_after.json()
+ bell_body = await bell_after.json()
+ assert conv_body["conversations"] == []
+ assert bell_body["unread_count"] == 0
+ assert bell_body["notifications"] == []
+
+ async def test_read_conversation_with_reaction_latest_stays_clear(self, bell_app):
+ bell_app.database.messages.upsert_lxmf_message(
+ _mk_message(
+ msg_hash="m1",
+ peer_hash=PEER_HASH,
+ content="hello",
+ timestamp=1_700_000_000,
+ ),
+ )
+ bell_app.database.messages.upsert_lxmf_message(
+ _mk_message(
+ msg_hash="r1",
+ peer_hash=PEER_HASH,
+ content="",
+ fields={
+ "reaction": {"reaction_to": "m1", "reaction_content": "\U0001f44d"}
+ },
+ timestamp=1_700_001_000,
+ ),
+ )
+ before = await self._get(bell_app, unread="true", limit=10)
+ assert before["unread_count"] == 1
+
+ await self._post(
+ bell_app,
+ f"/api/v1/lxmf/conversations/{PEER_HASH}/mark-as-read",
+ )
+
+ after = await self._get(bell_app, unread="true", limit=10)
+ assert after["unread_count"] == 0
+ assert after["notifications"] == []
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index a69cc1be..01d3af81 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -7,6 +7,7 @@ import DialogUtils from "@/js/DialogUtils";
import ToastUtils from "@/js/ToastUtils";
import { MESSAGE_BODY_MAX_DISPLAY_CHARS } from "../../meshchatx/src/frontend/js/messageDisplayLimits.js";
import DownloadUtils from "@/js/DownloadUtils";
+import GlobalEmitter from "@/js/GlobalEmitter";
vi.mock("@/js/DialogUtils", () => ({
default: {
@@ -14,6 +15,14 @@ vi.mock("@/js/DialogUtils", () => ({
},
}));
+vi.mock("@/js/GlobalEmitter", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ },
+}));
+
describe("ConversationViewer.vue", () => {
let axiosMock;
@@ -117,6 +126,7 @@ describe("ConversationViewer.vue", () => {
const wrapper = mountConversationViewer();
await flushPromises();
axiosMock.post.mockClear();
+ GlobalEmitter.emit.mockClear();
const conversation = { destination_hash: "unread-hash", is_unread: true };
await wrapper.vm.markConversationAsRead(conversation);
@@ -126,6 +136,25 @@ describe("ConversationViewer.vue", () => {
const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read"));
expect(markCalls).toHaveLength(1);
expect(wrapper.emitted("reload-conversations")).toHaveLength(1);
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
+ });
+
+ it("markConversationAsRead does not notify bell when server mark-as-read fails", async () => {
+ const wrapper = mountConversationViewer();
+ await flushPromises();
+ axiosMock.post.mockImplementation((url) => {
+ if (String(url).includes("/mark-as-read")) {
+ return Promise.reject(new Error("offline"));
+ }
+ return Promise.resolve({ data: {} });
+ });
+ GlobalEmitter.emit.mockClear();
+
+ const conversation = { destination_hash: "unread-hash", is_unread: true };
+ await wrapper.vm.markConversationAsRead(conversation);
+ await flushPromises();
+
+ expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed");
});
it("onMessagePaste adds images from clipboard and prevents default", async () => {
diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index bb49656a..bb91eaf2 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -1,6 +1,15 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import MessagesPage from "@/components/messages/MessagesPage.vue";
+import GlobalEmitter from "@/js/GlobalEmitter";
+
+vi.mock("@/js/GlobalEmitter", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ },
+}));
describe("MessagesPage.vue", () => {
let axiosMock;
@@ -428,4 +437,32 @@ describe("MessagesPage.vue", () => {
await wrapper.vm.onComposeNewMessage(destHash);
expect(wrapper.vm.selectedPeer.display_name).toBe("Existing Peer");
});
+
+ it("onBulkMarkAsRead notifies notification bell after server confirms", async () => {
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+ axiosMock.post.mockResolvedValue({ data: {} });
+ axiosMock.get.mockResolvedValue({ data: { conversations: [] } });
+ GlobalEmitter.emit.mockClear();
+
+ await wrapper.vm.onBulkMarkAsRead(["peer-a", "peer-b"]);
+ await wrapper.vm.$nextTick();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/lxmf/conversations/bulk-mark-as-read", {
+ destination_hashes: ["peer-a", "peer-b"],
+ });
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
+ });
+
+ it("onBulkMarkAsRead does not notify bell when server rejects", async () => {
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+ axiosMock.post.mockRejectedValue(new Error("fail"));
+ GlobalEmitter.emit.mockClear();
+
+ await wrapper.vm.onBulkMarkAsRead(["peer-a"]);
+ await wrapper.vm.$nextTick();
+
+ expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed");
+ });
});
diff --git a/tests/frontend/NotificationBell.test.js b/tests/frontend/NotificationBell.test.js
index 9457c9ed..545c1075 100644
--- a/tests/frontend/NotificationBell.test.js
+++ b/tests/frontend/NotificationBell.test.js
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
let wsHandlers = {};
+let emitterHandlers = {};
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
default: {
on: vi.fn((event, handler) => {
@@ -17,6 +18,23 @@ vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
},
}));
+vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
+ default: {
+ on: vi.fn((event, handler) => {
+ emitterHandlers[event] = emitterHandlers[event] || [];
+ emitterHandlers[event].push(handler);
+ }),
+ off: vi.fn((event, handler) => {
+ if (emitterHandlers[event]) {
+ emitterHandlers[event] = emitterHandlers[event].filter((h) => h !== handler);
+ }
+ }),
+ emit: vi.fn((event, payload) => {
+ (emitterHandlers[event] || []).forEach((h) => h(payload));
+ }),
+ },
+}));
+
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
default: { formatTimeAgo: (d) => "1h ago" },
}));
@@ -66,6 +84,7 @@ describe("NotificationBell UI", () => {
afterEach(() => {
wsHandlers = {};
+ emitterHandlers = {};
});
it("renders bell button", () => {
@@ -158,6 +177,7 @@ describe("NotificationBell websocket reliability", () => {
afterEach(() => {
wsHandlers = {};
+ emitterHandlers = {};
});
it("reloads on lxmf.delivery websocket event", async () => {
@@ -298,6 +318,7 @@ describe("NotificationBell false-trigger suppression", () => {
afterEach(() => {
wsHandlers = {};
+ emitterHandlers = {};
});
function expectNoNotificationsReload(callsBefore) {
@@ -535,6 +556,7 @@ describe("NotificationBell badge accuracy", () => {
afterEach(() => {
wsHandlers = {};
+ emitterHandlers = {};
});
it("badge hidden when unread count is 0", async () => {
@@ -636,6 +658,7 @@ describe("NotificationBell mark-as-viewed", () => {
afterEach(() => {
wsHandlers = {};
+ emitterHandlers = {};
});
it("calls mark-as-viewed API when dropdown is opened", async () => {
@@ -681,6 +704,7 @@ describe("NotificationBell history", () => {
afterEach(() => {
wsHandlers = {};
+ emitterHandlers = {};
});
it("shows history control when dropdown is open", async () => {
@@ -751,6 +775,7 @@ describe("NotificationBell clear all", () => {
afterEach(() => {
wsHandlers = {};
+ emitterHandlers = {};
});
it("clears all notifications and marks conversations as read", async () => {
@@ -795,3 +820,121 @@ describe("NotificationBell clear all", () => {
wrapper.unmount();
});
});
+
+describe("NotificationBell live sync", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ wsHandlers = {};
+ emitterHandlers = {};
+ global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
+ global.api.post = vi.fn().mockResolvedValue({ data: {} });
+ });
+
+ afterEach(() => {
+ wsHandlers = {};
+ emitterHandlers = {};
+ });
+
+ it("refreshes badge when conversations are marked read elsewhere", async () => {
+ let callCount = 0;
+ global.api.get = vi.fn().mockImplementation(() => {
+ callCount++;
+ if (callCount === 1) {
+ return Promise.resolve({ data: { notifications: [], unread_count: 3 } });
+ }
+ return Promise.resolve({ data: { notifications: [], unread_count: 0 } });
+ });
+
+ const wrapper = mountBell();
+ await wrapper.vm.$nextTick();
+ await new Promise((r) => setTimeout(r, 50));
+ expect(wrapper.vm.unreadCount).toBe(3);
+
+ (emitterHandlers["notifications-changed"] || []).forEach((h) => h());
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(wrapper.vm.unreadCount).toBe(0);
+ expect(global.api.get.mock.calls.length).toBeGreaterThanOrEqual(2);
+ wrapper.unmount();
+ });
+
+ it("subscribes to notifications-changed on mount and unsubscribes on destroy", () => {
+ const wrapper = mountBell();
+ expect(emitterHandlers["notifications-changed"]?.length).toBeGreaterThan(0);
+ const handler = emitterHandlers["notifications-changed"][0];
+ wrapper.unmount();
+ expect(emitterHandlers["notifications-changed"] || []).not.toContain(handler);
+ });
+
+ it("polls unread count every 5s while dropdown is closed", async () => {
+ vi.useFakeTimers();
+ let unread = 4;
+ global.api.get = vi.fn().mockImplementation(() =>
+ Promise.resolve({ data: { notifications: [], unread_count: unread } })
+ );
+
+ const wrapper = mountBell();
+ await vi.runOnlyPendingTimersAsync();
+ expect(wrapper.vm.unreadCount).toBe(4);
+
+ unread = 0;
+ await vi.advanceTimersByTimeAsync(5000);
+ await vi.runOnlyPendingTimersAsync();
+
+ expect(wrapper.vm.unreadCount).toBe(0);
+ expect(wrapper.vm.isDropdownOpen).toBe(false);
+ expect(global.api.get.mock.calls.length).toBeGreaterThanOrEqual(2);
+
+ vi.useRealTimers();
+ wrapper.unmount();
+ });
+
+ it("keeps badge visible until server confirms read (no optimistic clear)", async () => {
+ global.api.get = vi.fn().mockResolvedValue({
+ data: {
+ notifications: [
+ { type: "lxmf_message", destination_hash: "d1", display_name: "A", latest_message_preview: "hi" },
+ ],
+ unread_count: 1,
+ },
+ });
+
+ const wrapper = mountBell();
+ await wrapper.vm.$nextTick();
+ await new Promise((r) => setTimeout(r, 50));
+ expect(wrapper.find("span.bg-red-500").exists()).toBe(true);
+
+ (emitterHandlers["notifications-changed"] || []).forEach((h) => h());
+ await new Promise((r) => setTimeout(r, 50));
+ expect(wrapper.find("span.bg-red-500").exists()).toBe(true);
+
+ global.api.get.mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
+ (emitterHandlers["notifications-changed"] || []).forEach((h) => h());
+ await new Promise((r) => setTimeout(r, 50));
+ expect(wrapper.find("span.bg-red-500").exists()).toBe(false);
+
+ wrapper.unmount();
+ });
+
+ it("opening bell with already-read server state shows empty list and clears stale badge", async () => {
+ global.api.get = vi.fn().mockResolvedValue({
+ data: { notifications: [], unread_count: 0 },
+ });
+
+ const wrapper = mountBell({ attachTo: document.body });
+ wrapper.vm.unreadCount = 5;
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find("span.bg-red-500").text()).toBe("5");
+
+ await wrapper.find("button").trigger("click");
+ await new Promise((r) => setTimeout(r, 80));
+
+ expect(wrapper.vm.unreadCount).toBe(0);
+ expect(wrapper.vm.notifications).toEqual([]);
+ expect(document.body.textContent).toContain("No new notifications");
+ const markCalls = global.api.post.mock.calls.filter((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
+ expect(markCalls).toHaveLength(0);
+
+ wrapper.unmount();
+ });
+});
diff --git a/tests/frontend/NotificationBellConversationSync.test.js b/tests/frontend/NotificationBellConversationSync.test.js
new file mode 100644
index 00000000..2b52a9d2
--- /dev/null
+++ b/tests/frontend/NotificationBellConversationSync.test.js
@@ -0,0 +1,308 @@
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+let wsHandlers = {};
+let emitterHandlers = {};
+
+vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
+ default: {
+ on: vi.fn((event, handler) => {
+ wsHandlers[event] = wsHandlers[event] || [];
+ wsHandlers[event].push(handler);
+ }),
+ off: vi.fn((event, handler) => {
+ if (wsHandlers[event]) {
+ wsHandlers[event] = wsHandlers[event].filter((h) => h !== handler);
+ }
+ }),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
+ default: {
+ on: vi.fn((event, handler) => {
+ emitterHandlers[event] = emitterHandlers[event] || [];
+ emitterHandlers[event].push(handler);
+ }),
+ off: vi.fn((event, handler) => {
+ if (emitterHandlers[event]) {
+ emitterHandlers[event] = emitterHandlers[event].filter((h) => h !== handler);
+ }
+ }),
+ emit: vi.fn((event, payload) => {
+ (emitterHandlers[event] || []).forEach((h) => h(payload));
+ }),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
+ default: { formatTimeAgo: () => "1h ago" },
+}));
+
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
+import ConversationViewer from "../../meshchatx/src/frontend/components/messages/ConversationViewer.vue";
+
+const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
+
+const PEER_HASH = "bb".repeat(16);
+
+function simulateWsDelivery() {
+ const data = JSON.stringify({
+ type: "lxmf.delivery",
+ lxmf_message: { is_incoming: true, content: "hello", title: "", fields: {} },
+ });
+ (wsHandlers["message"] || []).forEach((h) => h({ data }));
+}
+
+function mountBell() {
+ return mount(NotificationBell, {
+ global: {
+ components: { MaterialDesignIcon },
+ directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
+ mocks: {
+ $router: { push: vi.fn() },
+ $t: (key) => {
+ const map = {
+ "app.notifications_no_new": "No new notifications",
+ "app.notifications_empty_history": "No notification history",
+ "app.notifications_history_title": "Recent notification history",
+ };
+ return map[key] || key;
+ },
+ },
+ },
+ });
+}
+
+function mountViewer() {
+ return mount(ConversationViewer, {
+ props: {
+ selectedPeer: { destination_hash: PEER_HASH, display_name: "Peer" },
+ myLxmfAddressHash: "aa".repeat(16),
+ conversations: [{ destination_hash: PEER_HASH, display_name: "Peer", is_unread: true }],
+ },
+ global: {
+ directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
+ mocks: {
+ $t: (key) => key,
+ $route: { meta: {} },
+ $router: { push: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: true,
+ AddImageButton: true,
+ AddAudioButton: true,
+ SendMessageButton: true,
+ ConversationDropDownMenu: true,
+ PaperMessageModal: true,
+ AudioWaveformPlayer: true,
+ LxmfUserIcon: true,
+ ConversationPeerHeader: true,
+ ConversationMessageEntry: true,
+ ConversationMessageListVirtual: true,
+ },
+ },
+ });
+}
+
+function createNotificationsApiMock() {
+ let conversationRead = false;
+ const get = vi.fn().mockImplementation((_url, config) => {
+ const unreadOnly = config?.params?.unread === true;
+ if (!conversationRead) {
+ const item = {
+ type: "lxmf_message",
+ destination_hash: PEER_HASH,
+ display_name: "Peer",
+ latest_message_preview: "hello",
+ updated_at: new Date().toISOString(),
+ };
+ return Promise.resolve({
+ data: {
+ notifications: unreadOnly ? [item] : [item],
+ unread_count: 1,
+ },
+ });
+ }
+ return Promise.resolve({
+ data: {
+ notifications: [],
+ unread_count: 0,
+ },
+ });
+ });
+ const post = vi.fn().mockImplementation((url) => {
+ if (String(url).includes("/mark-as-read")) {
+ conversationRead = true;
+ }
+ return Promise.resolve({ data: {} });
+ });
+ return { get, post, markRead: () => {
+ conversationRead = true;
+ }, isRead: () => conversationRead };
+}
+
+describe("NotificationBell conversation read sync", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ wsHandlers = {};
+ emitterHandlers = {};
+ window.URL.createObjectURL = vi.fn(() => "blob:mock");
+ vi.stubGlobal(
+ "FileReader",
+ vi.fn(function () {
+ return { readAsDataURL: vi.fn() };
+ })
+ );
+ vi.stubGlobal("localStorage", {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ wsHandlers = {};
+ emitterHandlers = {};
+ vi.unstubAllGlobals();
+ });
+
+ it("reproduces stale badge: delivery raises count, read in Messages clears without opening bell", async () => {
+ const api = createNotificationsApiMock();
+ window.api = { get: api.get, post: api.post };
+
+ const bell = mountBell();
+ await flushPromises();
+ expect(bell.vm.unreadCount).toBe(1);
+ expect(bell.find("span.bg-red-500").exists()).toBe(true);
+
+ simulateWsDelivery();
+ await flushPromises();
+ expect(bell.vm.unreadCount).toBe(1);
+
+ const viewer = mountViewer();
+ await flushPromises();
+ const conversation = { destination_hash: PEER_HASH, is_unread: true };
+ await viewer.vm.markConversationAsRead(conversation);
+ await flushPromises();
+
+ expect(api.post).toHaveBeenCalledWith(`/api/v1/lxmf/conversations/${PEER_HASH}/mark-as-read`);
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
+ expect(bell.vm.unreadCount).toBe(0);
+ expect(bell.find("span.bg-red-500").exists()).toBe(false);
+
+ bell.unmount();
+ viewer.unmount();
+ });
+
+ it("stale badge clears on bell click when server already has zero unread (empty dropdown)", async () => {
+ const api = createNotificationsApiMock();
+ api.markRead();
+ window.api = { get: api.get, post: api.post };
+
+ const bell = mountBell();
+ bell.vm.unreadCount = 3;
+ await bell.vm.$nextTick();
+ expect(bell.find("span.bg-red-500").text()).toBe("3");
+
+ await bell.find("button").trigger("click");
+ await flushPromises();
+ await new Promise((r) => setTimeout(r, 80));
+
+ expect(bell.vm.unreadCount).toBe(0);
+ expect(bell.vm.notifications).toEqual([]);
+ expect(document.body.textContent).toContain("No new notifications");
+
+ const markCalls = api.post.mock.calls.filter((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
+ expect(markCalls).toHaveLength(0);
+
+ bell.unmount();
+ });
+
+ it("background poll refreshes badge while dropdown stays closed", async () => {
+ vi.useFakeTimers();
+ const api = createNotificationsApiMock();
+ window.api = { get: api.get, post: api.post };
+
+ const bell = mountBell();
+ await flushPromises();
+ expect(bell.vm.unreadCount).toBe(1);
+
+ api.markRead();
+ await vi.advanceTimersByTimeAsync(5000);
+ await flushPromises();
+
+ expect(bell.vm.unreadCount).toBe(0);
+ expect(bell.vm.isDropdownOpen).toBe(false);
+
+ vi.useRealTimers();
+ bell.unmount();
+ });
+
+ it("does not emit notifications-changed when mark-as-read API fails", async () => {
+ const api = createNotificationsApiMock();
+ api.post.mockImplementation((url) => {
+ if (String(url).includes("/mark-as-read")) {
+ return Promise.reject(new Error("network"));
+ }
+ return Promise.resolve({ data: {} });
+ });
+ window.api = { get: api.get, post: api.post };
+
+ const viewer = mountViewer();
+ await flushPromises();
+ GlobalEmitter.emit.mockClear();
+
+ const conversation = { destination_hash: PEER_HASH, is_unread: true };
+ await viewer.vm.markConversationAsRead(conversation);
+ await flushPromises();
+
+ expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed");
+ expect(conversation.is_unread).toBe(false);
+
+ viewer.unmount();
+ });
+
+ it("badge count tracks API unread_count after websocket delivery", async () => {
+ let unread = 0;
+ window.api = {
+ get: vi.fn().mockImplementation(() =>
+ Promise.resolve({
+ data: {
+ notifications:
+ unread > 0
+ ? [
+ {
+ type: "lxmf_message",
+ destination_hash: PEER_HASH,
+ display_name: "Peer",
+ latest_message_preview: "ping",
+ },
+ ]
+ : [],
+ unread_count: unread,
+ },
+ })
+ ),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ };
+
+ const bell = mountBell();
+ await flushPromises();
+ expect(bell.vm.unreadCount).toBe(0);
+
+ unread = 2;
+ simulateWsDelivery();
+ await flushPromises();
+ expect(bell.vm.unreadCount).toBe(2);
+ expect(bell.find("span.bg-red-500").text()).toBe("2");
+
+ unread = 0;
+ GlobalEmitter.emit("notifications-changed");
+ await flushPromises();
+ expect(bell.vm.unreadCount).toBe(0);
+
+ bell.unmount();
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────